// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:typed_data';

import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';

import '../image_data.dart';
import '../rendering/rendering_tester.dart' show TestCallbackPainter;

late List<int> selectedTabs;

class MockCupertinoTabController extends CupertinoTabController {
  MockCupertinoTabController({ required super.initialIndex });

  bool isDisposed = false;
  int numOfListeners = 0;

  @override
  void addListener(VoidCallback listener) {
    numOfListeners++;
    super.addListener(listener);
  }

  @override
  void removeListener(VoidCallback listener) {
    numOfListeners--;
    super.removeListener(listener);
  }

  @override
  void dispose() {
    isDisposed = true;
    super.dispose();
  }
}

void main() {
  setUp(() {
    selectedTabs = <int>[];
  });

  BottomNavigationBarItem tabGenerator(int index) {
    return BottomNavigationBarItem(
      icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))),
      label: 'Tab ${index + 1}',
    );
  }

  testWidgets('Tab switching', (WidgetTester tester) async {
    final List<int> tabsPainted = <int>[];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int>[0]);
    RichText tab1 = tester.widget(find.descendant(
      of: find.text('Tab 1'),
      matching: find.byType(RichText),
    ));
    expect(tab1.text.style!.color, CupertinoColors.activeBlue);
    RichText tab2 = tester.widget(find.descendant(
      of: find.text('Tab 2'),
      matching: find.byType(RichText),
    ));
    expect(tab2.text.style!.color!.value, 0xFF999999);

    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    expect(tabsPainted, const <int>[0, 1]);
    tab1 = tester.widget(find.descendant(
      of: find.text('Tab 1'),
      matching: find.byType(RichText),
    ));
    expect(tab1.text.style!.color!.value, 0xFF999999);
    tab2 = tester.widget(find.descendant(
      of: find.text('Tab 2'),
      matching: find.byType(RichText),
    ));
    expect(tab2.text.style!.color, CupertinoColors.activeBlue);

    await tester.tap(find.text('Tab 1'));
    await tester.pump();

    expect(tabsPainted, const <int>[0, 1, 0]);
    // CupertinoTabBar's onTap callbacks are passed on.
    expect(selectedTabs, const <int>[1, 0]);
  });

  testWidgets('Tabs are lazy built and moved offstage when inactive', (WidgetTester tester) async {
    final List<int> tabsBuilt = <int>[];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            tabsBuilt.add(index);
            return Text('Page ${index + 1}');
          },
        ),
      ),
    );

    expect(tabsBuilt, const <int>[0]);
    expect(find.text('Page 1'), findsOneWidget);
    expect(find.text('Page 2'), findsNothing);

    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    // Both tabs are built but only one is onstage.
    expect(tabsBuilt, const <int>[0, 0, 1]);
    expect(find.text('Page 1', skipOffstage: false), isOffstage);
    expect(find.text('Page 2'), findsOneWidget);

    await tester.tap(find.text('Tab 1'));
    await tester.pump();

    expect(tabsBuilt, const <int>[0, 0, 1, 0, 1]);
    expect(find.text('Page 1'), findsOneWidget);
    expect(find.text('Page 2', skipOffstage: false), isOffstage);
  });

  testWidgets('Last tab gets focus', (WidgetTester tester) async {
    // 2 nodes for 2 tabs
    final List<FocusNode> focusNodes = <FocusNode>[
      FocusNode(debugLabel: 'Node 1'),
      FocusNode(debugLabel: 'Node 2'),
    ];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return CupertinoTextField(
              focusNode: focusNodes[index],
              autofocus: true,
            );
          },
        ),
      ),
    );

    expect(focusNodes[0].hasFocus, isTrue);

    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    expect(focusNodes[0].hasFocus, isFalse);
    expect(focusNodes[1].hasFocus, isTrue);

    await tester.tap(find.text('Tab 1'));
    await tester.pump();

    expect(focusNodes[0].hasFocus, isTrue);
    expect(focusNodes[1].hasFocus, isFalse);
  });

  testWidgets('Do not affect focus order in the route', (WidgetTester tester) async {
    final List<FocusNode> focusNodes = <FocusNode>[
      FocusNode(debugLabel: 'Node 1'),
      FocusNode(debugLabel: 'Node 2'),
      FocusNode(debugLabel: 'Node 3'),
      FocusNode(debugLabel: 'Node 4'),
    ];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return Column(
              children: <Widget>[
                CupertinoTextField(
                  focusNode: focusNodes[index * 2],
                  placeholder: 'TextField 1',
                ),
                CupertinoTextField(
                  focusNode: focusNodes[index * 2 + 1],
                  placeholder: 'TextField 2',
                ),
              ],
            );
          },
        ),
      ),
    );

    expect(
      focusNodes.any((FocusNode node) => node.hasFocus),
      isFalse,
    );

    await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 2'));

    expect(
      focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)),
      1,
    );

    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    await tester.tap(find.widgetWithText(CupertinoTextField, 'TextField 1'));

    expect(
      focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)),
      2,
    );

    await tester.tap(find.text('Tab 1'));
    await tester.pump();

    // Upon going back to tab 1, the item it tab 1 that previously had the focus
    // (TextField 2) gets it back.
    expect(
      focusNodes.indexOf(focusNodes.singleWhere((FocusNode node) => node.hasFocus)),
      1,
    );
  });

  testWidgets('Programmatic tab switching by changing the index of an existing controller', (WidgetTester tester) async {
    final CupertinoTabController controller = CupertinoTabController(initialIndex: 1);
    final List<int> tabsPainted = <int>[];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          controller: controller,
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int>[1]);

    controller.index = 0;
    await tester.pump();

    expect(tabsPainted, const <int>[1, 0]);
    // onTap is not called when changing tabs programmatically.
    expect(selectedTabs, isEmpty);

    // Can still tap out of the programmatically selected tab.
    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    expect(tabsPainted, const <int>[1, 0, 1]);
    expect(selectedTabs, const <int>[1]);
  });

  testWidgets('Programmatic tab switching by passing in a new controller', (WidgetTester tester) async {
    final List<int> tabsPainted = <int>[];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int>[0]);

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          controller: CupertinoTabController(initialIndex: 1), // Programmatically change the tab now.
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int>[0, 1]);
    // onTap is not called when changing tabs programmatically.
    expect(selectedTabs, isEmpty);

    // Can still tap out of the programmatically selected tab.
    await tester.tap(find.text('Tab 1'));
    await tester.pump();

    expect(tabsPainted, const <int>[0, 1, 0]);
    expect(selectedTabs, const <int>[0]);
  });

  testWidgets('Tab bar respects themes', (WidgetTester tester) async {
    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return const Placeholder();
          },
        ),
      ),
    );

    BoxDecoration tabDecoration = tester.widget<DecoratedBox>(find.descendant(
      of: find.byType(CupertinoTabBar),
      matching: find.byType(DecoratedBox),
    )).decoration as BoxDecoration;

    expect(tabDecoration.color, isSameColorAs(const Color(0xF0F9F9F9))); // Inherited from theme.

    await tester.tap(find.text('Tab 2'));
    await tester.pump();

    // Pump again but with dark theme.
    await tester.pumpWidget(
      CupertinoApp(
        theme: const CupertinoThemeData(
          brightness: Brightness.dark,
          primaryColor: CupertinoColors.destructiveRed,
        ),
        home: CupertinoTabScaffold(
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return const Placeholder();
          },
        ),
      ),
    );

    tabDecoration = tester.widget<DecoratedBox>(find.descendant(
      of: find.byType(CupertinoTabBar),
      matching: find.byType(DecoratedBox),
    )).decoration as BoxDecoration;

    expect(tabDecoration.color, isSameColorAs(const Color(0xF01D1D1D)));

    final RichText tab1 = tester.widget(find.descendant(
      of: find.text('Tab 1'),
      matching: find.byType(RichText),
    ));
    // Tab 2 should still be selected after changing theme.
    expect(tab1.text.style!.color!.value, 0xFF757575);
    final RichText tab2 = tester.widget(find.descendant(
      of: find.text('Tab 2'),
      matching: find.byType(RichText),
    ));
    expect(tab2.text.style!.color, isSameColorAs(CupertinoColors.systemRed.darkColor));
  });

  testWidgets('Tab contents are padded when there are view insets', (WidgetTester tester) async {
    late BuildContext innerContext;

    await tester.pumpWidget(
      CupertinoApp(
        home: MediaQuery(
          data: const MediaQueryData(
            viewInsets: EdgeInsets.only(bottom: 200),
          ),
          child: CupertinoTabScaffold(
            tabBar: _buildTabBar(),
            tabBuilder: (BuildContext context, int index) {
              innerContext = context;
              return const Placeholder();
            },
          ),
        ),
      ),
    );

    expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400));
    // Don't generate more media query padding from the translucent bottom
    // tab since the tab is behind the keyboard now.
    expect(MediaQuery.of(innerContext).padding.bottom, 0);
  });

  testWidgets('Tab contents are not inset when resizeToAvoidBottomInset overridden', (WidgetTester tester) async {
    late BuildContext innerContext;

    await tester.pumpWidget(
      CupertinoApp(
        home: MediaQuery(
          data: const MediaQueryData(
            viewInsets: EdgeInsets.only(bottom: 200),
          ),
          child: CupertinoTabScaffold(
            resizeToAvoidBottomInset: false,
            tabBar: _buildTabBar(),
            tabBuilder: (BuildContext context, int index) {
              innerContext = context;
              return const Placeholder();
            },
          ),
        ),
      ),
    );

    expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 600));
    // Media query padding shows up in the inner content because it wasn't masked
    // by the view inset.
    expect(MediaQuery.of(innerContext).padding.bottom, 50);
  });

  testWidgets('Tab contents bottom padding are not consumed by viewInsets when resizeToAvoidBottomInset overridden', (WidgetTester tester) async {
    final Widget child = Localizations(
      locale: const Locale('en', 'US'),
      delegates: const <LocalizationsDelegate<dynamic>>[
        DefaultWidgetsLocalizations.delegate,
        DefaultCupertinoLocalizations.delegate,
      ],
      child: Directionality(
        textDirection: TextDirection.ltr,
        child: CupertinoTabScaffold(
          resizeToAvoidBottomInset: false,
          tabBar: _buildTabBar(),
          tabBuilder: (BuildContext context, int index) {
            return const Placeholder();
          },
        ),
      ),
    );

    await tester.pumpWidget(
      CupertinoApp(
        home: MediaQuery(
          data: const MediaQueryData(
            viewInsets: EdgeInsets.only(bottom: 20.0),
          ),
          child: child,
        ),
      ),
    );

    final Offset initialPoint = tester.getCenter(find.byType(Placeholder));

    // Consume bottom padding - as if by the keyboard opening
    await tester.pumpWidget(
      MediaQuery(
        data: const MediaQueryData(
          viewPadding: EdgeInsets.only(bottom: 20),
          viewInsets: EdgeInsets.only(bottom: 300),
        ),
        child: child,
      ),
    );

    final Offset finalPoint = tester.getCenter(find.byType(Placeholder));

    expect(initialPoint, finalPoint);
  });

  testWidgets(
    'Opaque tab bar consumes bottom padding while non opaque tab bar does not',
    (WidgetTester tester) async {
      // Regression test for https://github.com/flutter/flutter/issues/43581.
      Future<EdgeInsets> getContentPaddingWithTabBarColor(Color color) async {
        late EdgeInsets contentPadding;

        await tester.pumpWidget(
          CupertinoApp(
            home: MediaQuery(
              data: const MediaQueryData(padding: EdgeInsets.only(bottom: 50)),
              child: CupertinoTabScaffold(
                tabBar: CupertinoTabBar(
                  backgroundColor: color,
                  items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
                ),
                tabBuilder: (BuildContext context, int index) {
                  contentPadding = MediaQuery.of(context).padding;
                  return const Placeholder();
                },
              ),
            ),
          ),
        );
        return contentPadding;
      }

      expect(await getContentPaddingWithTabBarColor(const Color(0xAAFFFFFF)), isNot(EdgeInsets.zero));
      expect(await getContentPaddingWithTabBarColor(const Color(0xFFFFFFFF)), EdgeInsets.zero);
    },
  );

  testWidgets('Tab and page scaffolds do not double stack view insets', (WidgetTester tester) async {
    late BuildContext innerContext;

    await tester.pumpWidget(
      CupertinoApp(
        home: MediaQuery(
          data: const MediaQueryData(
            viewInsets: EdgeInsets.only(bottom: 200),
          ),
          child: CupertinoTabScaffold(
            tabBar: _buildTabBar(),
            tabBuilder: (BuildContext context, int index) {
              return CupertinoPageScaffold(
                child: Builder(
                  builder: (BuildContext context) {
                    innerContext = context;
                    return const Placeholder();
                  },
                ),
              );
            },
          ),
        ),
      ),
    );

    expect(tester.getRect(find.byType(Placeholder)), const Rect.fromLTWH(0, 0, 800, 400));
    expect(MediaQuery.of(innerContext).padding.bottom, 0);
  });

  testWidgets('Deleting tabs after selecting them should switch to the last available tab', (WidgetTester tester) async {
    final List<int> tabsBuilt = <int>[];

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(4, tabGenerator),
            onTap: (int newTab) => selectedTabs.add(newTab),
          ),
          tabBuilder: (BuildContext context, int index) {
            tabsBuilt.add(index);
            return Text('Page ${index + 1}');
          },
        ),
      ),
    );

    expect(tabsBuilt, const <int>[0]);
    // selectedTabs list is appended to on onTap callbacks. We didn't tap
    // any tabs yet.
    expect(selectedTabs, const <int>[]);
    tabsBuilt.clear();

    await tester.tap(find.text('Tab 4'));
    await tester.pump();

    // Tabs 1 and 4 are built but only one is onstage.
    expect(tabsBuilt, const <int>[0, 3]);
    expect(selectedTabs, const <int>[3]);
    expect(find.text('Page 1', skipOffstage: false), isOffstage);
    expect(find.text('Page 4'), findsOneWidget);
    tabsBuilt.clear();

    // Delete 2 tabs while Page 4 is still selected.
    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
            onTap: (int newTab) => selectedTabs.add(newTab),
          ),
          tabBuilder: (BuildContext context, int index) {
            tabsBuilt.add(index);
            // Change the builder too.
            return Text('Different page ${index + 1}');
          },
        ),
      ),
    );

    expect(tabsBuilt, const <int>[0, 1]);
    // We didn't tap on any additional tabs to invoke the onTap callback. We
    // just deleted a tab.
    expect(selectedTabs, const <int>[3]);
    // Tab 1 was previously built so it's rebuilt again, albeit offstage.
    expect(find.text('Different page 1', skipOffstage: false), isOffstage);
    // Since all the tabs after tab 2 are deleted, tab 2 is now the last tab and
    // the actively shown tab.
    expect(find.text('Different page 2'), findsOneWidget);
    // No more tab 4 since it's deleted.
    expect(find.text('Different page 4', skipOffstage: false), findsNothing);
    // We also changed the builder so no tabs should be built with the old
    // builder.
    expect(find.text('Page 1', skipOffstage: false), findsNothing);
    expect(find.text('Page 2', skipOffstage: false), findsNothing);
    expect(find.text('Page 4', skipOffstage: false), findsNothing);
  });

  // Regression test for https://github.com/flutter/flutter/issues/33455
  testWidgets('Adding new tabs does not crash the app', (WidgetTester tester) async {
    final List<int> tabsPainted = <int>[];
    final CupertinoTabController controller = CupertinoTabController();

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(10, tabGenerator),
          ),
          controller: controller,
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int> [0]);

    // Increase the num of tabs to 20.
    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(20, tabGenerator),
          ),
          controller: controller,
          tabBuilder: (BuildContext context, int index) {
            return CustomPaint(
              painter: TestCallbackPainter(
                onPaint: () { tabsPainted.add(index); },
              ),
              child: Text('Page ${index + 1}'),
            );
          },
        ),
      ),
    );

    expect(tabsPainted, const <int> [0, 0]);

    await tester.tap(find.text('Tab 19'));
    await tester.pump();

    // Tapping the tabs should still work.
    expect(tabsPainted, const <int>[0, 0, 18]);
  });

  testWidgets(
    'If a controller is initially provided then the parent stops doing so for rebuilds, '
    'a new instance of CupertinoTabController should be created and used by the widget, '
    "while preserving the previous controller's tab index",
    (WidgetTester tester) async {
      final List<int> tabsPainted = <int>[];
      final CupertinoTabController oldController = CupertinoTabController();

      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(10, tabGenerator),
            ),
            controller: oldController,
            tabBuilder: (BuildContext context, int index) {
              return CustomPaint(
                painter: TestCallbackPainter(
                  onPaint: () { tabsPainted.add(index); },
                ),
                child: Text('Page ${index + 1}'),
              );
            },
          ),
        ),
      );

      expect(tabsPainted, const <int> [0]);

      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(10, tabGenerator),
            ),
            tabBuilder:
            (BuildContext context, int index) {
              return CustomPaint(
                painter: TestCallbackPainter(
                  onPaint: () { tabsPainted.add(index); },
                ),
                child: Text('Page ${index + 1}'),
              );
            },
          ),
        ),
      );

      expect(tabsPainted, const <int> [0, 0]);

      await tester.tap(find.text('Tab 2'));
      await tester.pump();

      // Tapping the tabs should still work.
      expect(tabsPainted, const <int>[0, 0, 1]);

      oldController.index = 10;
      await tester.pump();

      // Changing [index] of the oldController should not work.
      expect(tabsPainted, const <int> [0, 0, 1]);
    },
  );

  testWidgets(
    'Do not call dispose on a controller that we do not own '
    'but do remove from its listeners when done listening to it',
    (WidgetTester tester) async {
      final MockCupertinoTabController mockController = MockCupertinoTabController(initialIndex: 0);

      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
            ),
            controller: mockController,
            tabBuilder: (BuildContext context, int index) => const Placeholder(),
          ),
        ),
      );

      expect(mockController.numOfListeners, 1);
      expect(mockController.isDisposed, isFalse);

      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
            ),
            tabBuilder: (BuildContext context, int index) => const Placeholder(),
          ),
        ),
      );

      expect(mockController.numOfListeners, 0);
      expect(mockController.isDisposed, isFalse);
    },
  );

  testWidgets('The owner can dispose the old controller', (WidgetTester tester) async {
    CupertinoTabController controller = CupertinoTabController(initialIndex: 2);

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
          ),
          controller: controller,
          tabBuilder: (BuildContext context, int index) => const Placeholder(),
        ),
      ),
    );
    expect(find.text('Tab 1'), findsOneWidget);
    expect(find.text('Tab 2'), findsOneWidget);
    expect(find.text('Tab 3'), findsOneWidget);

    controller.dispose();
    controller = CupertinoTabController();
    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
          ),
          controller: controller,
          tabBuilder: (BuildContext context, int index) => const Placeholder(),
        ),
      ),
    );

    // Should not crash here.
    expect(find.text('Tab 1'), findsOneWidget);
    expect(find.text('Tab 2'), findsOneWidget);
    expect(find.text('Tab 3'), findsNothing);
  });

  testWidgets('A controller can control more than one CupertinoTabScaffold, '
    'removal of listeners does not break the controller',
    (WidgetTester tester) async {
      final List<int> tabsPainted0 = <int>[];
      final List<int> tabsPainted1 = <int>[];
      MockCupertinoTabController controller = MockCupertinoTabController(initialIndex: 2);

      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoPageScaffold(
            child: Stack(
              children: <Widget>[
                CupertinoTabScaffold(
                  tabBar: CupertinoTabBar(
                    items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
                  ),
                  controller: controller,
                  tabBuilder: (BuildContext context, int index) {
                    return CustomPaint(
                      painter: TestCallbackPainter(
                        onPaint: () => tabsPainted0.add(index),
                      ),
                    );
                  },
                ),
                CupertinoTabScaffold(
                  tabBar: CupertinoTabBar(
                    items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
                  ),
                  controller: controller,
                  tabBuilder: (BuildContext context, int index) {
                    return CustomPaint(
                      painter: TestCallbackPainter(
                        onPaint: () => tabsPainted1.add(index),
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
        ),
      );
      expect(tabsPainted0, const <int>[2]);
      expect(tabsPainted1, const <int>[2]);
      expect(controller.numOfListeners, 2);

      controller.index = 0;
      await tester.pump();
      expect(tabsPainted0, const <int>[2, 0]);
      expect(tabsPainted1, const <int>[2, 0]);

      controller.index = 1;
      // Removing one of the tabs works.
      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoPageScaffold(
            child: Stack(
              children: <Widget>[
                CupertinoTabScaffold(
                  tabBar: CupertinoTabBar(
                    items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
                  ),
                  controller: controller,
                  tabBuilder: (BuildContext context, int index) {
                    return CustomPaint(
                      painter: TestCallbackPainter(
                        onPaint: () => tabsPainted0.add(index),
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
        ),
      );

      expect(tabsPainted0, const <int>[2, 0, 1]);
      expect(tabsPainted1, const <int>[2, 0]);
      expect(controller.numOfListeners, 1);

      // Replacing controller works.
      controller = MockCupertinoTabController(initialIndex: 2);
      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoPageScaffold(
            child: Stack(
              children: <Widget>[
                CupertinoTabScaffold(
                  tabBar: CupertinoTabBar(
                    items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
                  ),
                  controller: controller,
                  tabBuilder: (BuildContext context, int index) {
                    return CustomPaint(
                      painter: TestCallbackPainter(
                        onPaint: () => tabsPainted0.add(index),
                      ),
                    );
                  },
                ),
              ],
            ),
          ),
        ),
      );
      expect(tabsPainted0, const <int>[2, 0, 1, 2]);
      expect(tabsPainted1, const <int>[2, 0]);
      expect(controller.numOfListeners, 1);
    },
  );

  testWidgets('Assert when current tab index >= number of tabs', (WidgetTester tester) async {
    final CupertinoTabController controller = CupertinoTabController(initialIndex: 2);

    try {
      await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(2, tabGenerator),
            ),
            controller: controller,
            tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'),
          ),
        ),
      );
    } on AssertionError catch (e) {
      expect(e.toString(), contains('controller.index < tabBar.items.length'));
    }

    await tester.pumpWidget(
      CupertinoApp(
        home: CupertinoTabScaffold(
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
          ),
          controller: controller,
          tabBuilder: (BuildContext context, int index) => Text('Different page ${index + 1}'),
        ),
      ),
    );

    expect(tester.takeException(), null);

    controller.index = 10;
    await tester.pump();

    final String message = tester.takeException().toString();
    expect(message, contains('current index ${controller.index}'));
    expect(message, contains('with 3 tabs'));
  });

  testWidgets("Don't replace focus nodes for existing tabs when changing tab count", (WidgetTester tester) async {
    final CupertinoTabController controller = CupertinoTabController(initialIndex: 2);

    final List<FocusScopeNode> scopes = List<FocusScopeNode>.filled(5, FocusScopeNode());
    await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(3, tabGenerator),
            ),
            controller: controller,
            tabBuilder: (BuildContext context, int index) {
              scopes[index] = FocusScope.of(context);
              return Container();
            },
          ),
        ),
    );

    for (int i = 0; i < 3; i++) {
      controller.index = i;
      await tester.pump();
    }
    await tester.pump();

    final List<FocusScopeNode> newScopes = <FocusScopeNode>[];
    await tester.pumpWidget(
        CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: CupertinoTabBar(
              items: List<BottomNavigationBarItem>.generate(5, tabGenerator),
            ),
            controller: controller,
            tabBuilder: (BuildContext context, int index) {
              newScopes.add(FocusScope.of(context));
              return Container();
            },
          ),
        ),
    );
    for (int i = 0; i < 5; i++) {
      controller.index = i;
      await tester.pump();
    }
    await tester.pump();

    expect(scopes.sublist(0, 3), equals(newScopes.sublist(0, 3)));
  });

  testWidgets('Current tab index cannot go below zero or be null', (WidgetTester tester) async {
    void expectAssertionError(VoidCallback callback, String errorMessage) {
      try {
        callback();
      } on AssertionError catch (e) {
        expect(e.toString(), contains(errorMessage));
      }
    }

    expectAssertionError(() => CupertinoTabController(initialIndex: -1), '>= 0');

    final CupertinoTabController controller = CupertinoTabController();

    expectAssertionError(() => controller.index = -1, '>= 0');
  });

  testWidgets('Does not lose state when focusing on text input', (WidgetTester tester) async {
    // Regression testing for https://github.com/flutter/flutter/issues/28457.

    await tester.pumpWidget(
      MediaQuery(
        data: const MediaQueryData(),
        child: CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: _buildTabBar(),
            tabBuilder: (BuildContext context, int index) {
              return const CupertinoTextField();
            },
          ),
        ),
      ),
    );

    final EditableTextState editableState = tester.state<EditableTextState>(find.byType(EditableText));

    await tester.enterText(find.byType(CupertinoTextField), "don't lose me");

    await tester.pumpWidget(
      MediaQuery(
        data: const MediaQueryData(
          viewInsets:  EdgeInsets.only(bottom: 100),
        ),
        child: CupertinoApp(
          home: CupertinoTabScaffold(
            tabBar: _buildTabBar(),
            tabBuilder: (BuildContext context, int index) {
              return const CupertinoTextField();
            },
          ),
        ),
      ),
    );

    // The exact same state instance is still there.
    expect(tester.state<EditableTextState>(find.byType(EditableText)), editableState);
    expect(find.text("don't lose me"), findsOneWidget);
  });

  testWidgets('textScaleFactor is set to 1.0', (WidgetTester tester) async {
    await tester.pumpWidget(
      CupertinoApp(
        home: Builder(builder: (BuildContext context) {
          return MediaQuery(
            data: MediaQuery.of(context).copyWith(textScaleFactor: 99),
            child: CupertinoTabScaffold(
              tabBar: CupertinoTabBar(
                items: List<BottomNavigationBarItem>.generate(
                  10,
                  (int i) => BottomNavigationBarItem(icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))), label: '$i'),
                ),
              ),
              tabBuilder: (BuildContext context, int index) => const Text('content'),
            ),
          );
        }),
      ),
    );

    final Iterable<RichText> barItems = tester.widgetList<RichText>(
      find.descendant(
        of: find.byType(CupertinoTabBar),
        matching: find.byType(RichText),
      ),
    );

    final Iterable<RichText> contents = tester.widgetList<RichText>(
      find.descendant(
        of: find.text('content'),
        matching: find.byType(RichText),
        skipOffstage: false,
      ),
    );

    expect(barItems.length, greaterThan(0));
    expect(barItems.any((RichText t) => t.textScaleFactor != 1), isFalse);

    expect(contents.length, greaterThan(0));
    expect(contents.any((RichText t) => t.textScaleFactor != 99), isFalse);
  });

  testWidgets('state restoration', (WidgetTester tester) async {
    await tester.pumpWidget(
      CupertinoApp(
        restorationScopeId: 'app',
        home: CupertinoTabScaffold(
          restorationId: 'scaffold',
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(
              4,
              (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'),
            ),
          ),
          tabBuilder: (BuildContext context, int i) => Text('Content $i'),
        ),
      ),
    );

    expect(find.text('Content 0'), findsOneWidget);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsNothing);
    expect(find.text('Content 3'), findsNothing);

    await tester.tap(find.text('Tab 2'));
    await tester.pumpAndSettle();

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsOneWidget);
    expect(find.text('Content 3'), findsNothing);

    await tester.restartAndRestore();

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsOneWidget);
    expect(find.text('Content 3'), findsNothing);

    final TestRestorationData data = await tester.getRestorationData();

    await tester.tap(find.text('Tab 1'));
    await tester.pumpAndSettle();

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsOneWidget);
    expect(find.text('Content 2'), findsNothing);
    expect(find.text('Content 3'), findsNothing);

    await tester.restoreFrom(data);

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsOneWidget);
    expect(find.text('Content 3'), findsNothing);
  });

  testWidgets('switch from internal to external controller with state restoration', (WidgetTester tester) async {
    Widget buildWidget({CupertinoTabController? controller}) {
      return CupertinoApp(
        restorationScopeId: 'app',
        home: CupertinoTabScaffold(
          controller: controller,
          restorationId: 'scaffold',
          tabBar: CupertinoTabBar(
            items: List<BottomNavigationBarItem>.generate(
              4,
              (int i) => BottomNavigationBarItem(icon: const Icon(CupertinoIcons.map), label: 'Tab $i'),
            ),
          ),
          tabBuilder: (BuildContext context, int i) => Text('Content $i'),
        ),
      );
    }

    await tester.pumpWidget(buildWidget());

    expect(find.text('Content 0'), findsOneWidget);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsNothing);
    expect(find.text('Content 3'), findsNothing);

    await tester.tap(find.text('Tab 2'));
    await tester.pumpAndSettle();

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsOneWidget);
    expect(find.text('Content 3'), findsNothing);

    final CupertinoTabController controller = CupertinoTabController(initialIndex: 3);
    await tester.pumpWidget(buildWidget(controller: controller));

    expect(find.text('Content 0'), findsNothing);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsNothing);
    expect(find.text('Content 3'), findsOneWidget);

    await tester.pumpWidget(buildWidget());

    expect(find.text('Content 0'), findsOneWidget);
    expect(find.text('Content 1'), findsNothing);
    expect(find.text('Content 2'), findsNothing);
    expect(find.text('Content 3'), findsNothing);
  });
}

CupertinoTabBar _buildTabBar({ int selectedTab = 0 }) {
  return CupertinoTabBar(
    items: <BottomNavigationBarItem>[
      BottomNavigationBarItem(
        icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))),
        label: 'Tab 1',
      ),
      BottomNavigationBarItem(
        icon: ImageIcon(MemoryImage(Uint8List.fromList(kTransparentImage))),
        label: 'Tab 2',
      ),
    ],
    currentIndex: selectedTab,
    onTap: (int newTab) => selectedTabs.add(newTab),
  );
}
